perf: check for subscribers before building the per-call context in wrappers - #102
Open
pabloerhard wants to merge 1 commit into
Open
pabloerhard wants to merge 1 commit into
pabloerhard wants to merge 1 commit into
Conversation
Signed-off-by: Pablo Erhard <pablo.erhardhernandez@datadoghq.com>
pabloerhard
marked this pull request as draft
October 1, 2026 19:00
pabloerhard
marked this pull request as ready for review
October 1, 2026 19:10
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Generated wrappers built their per-call transport on every call, even with no subscribers: the arguments array plus its
.slice(0, arguments.length)copy, the__apm$ctxobject, and the__apm$tracedclosure. They only checkedtr_ch_apm_hasSubscribersafter that. Callback wrappers also ranArray.prototype.atand created the hoisted__apm$wrappedCbclosure first, Auto ranatfirst, and patched iterator methods copiedargumentsbefore checking.This moves the check to just after the
__apm$traceddeclaration, which now receives the argument list as a parameter. An unsubscribed call goes straight to the original witharguments(or[params]when the wrapper is an arrow, which has no ownarguments). The now-duplicate checks are removed from the Sync/Async templates,__apm$wrappedCbbecomes aconstfunction expression, and iterator methods check before copying.The AST shape that idempotency and downstream custom transforms depend on is unchanged:
const __apm$traced = <arrow>holdingconst __apm$wrapped = <fn>, a top-level__apm$ctxobject, a top-levelif (!tr_ch_apm_hasSubscribers(ch)) return __apm$traced(...)check, and two__apm$tracedcalls in Sync/Async wrappers.Why behaviour is unchanged
__apm$argumentsarray afterstart, so in-place mutation (replace, append, callback splice) still reaches it.argumentslists exactly what[...].slice(0, arguments.length)produced.undefined. Parameters, defaults and rest bind the same either way, andargumentsinside the moved arrow body is still the constructor's own.new.targetis unchanged.thisis still lexical through the arrow__apm$traced. Generator and async flags stay on the inner function.__apm$supersetup is still added above everything.runStores.ctxper call,startviarunStores,endinfinally,ctx.errorset beforeerrorpublishes, and result replacement.Tests
fast_path_cjs: every kind (Sync, Async, Callback, Auto, Iterator/AsyncIteratorreturnKind, class method, base and derived constructor, arrow expression, runtime-patched instance method) with fewer, exact and more arguments. It goes unsubscribed → subscribed → unsubscribed and spies onslice/atto assert no allocation on the fast path. It also pins iteratornextwith only the main channel subscribed, and Callback/Auto with onlyasyncEndsubscribed.arguments_mutation_kinds_cjs: subscribed in-place mutation for Sync, Async, Callback and constructors.fast path ordering: checks the generated code for the ordering above.The behaviour assertions pass unchanged on
main; only the allocation and ordering checks fail there.Benchmarks
Node v25.0.0, Apple M4 Max, 11 fresh processes per side, median ns/op:
next())Subscribed calls are within noise. With all five events and noop handlers: Sync −1.8%, Async +1.9%, Callback −0.3%, Auto −0.3%, method −1.8%, ctor +1.7%, Iterator −1.1% (spread per side is 2–13%). With an
AsyncLocalStoragebound tostart: Sync +0.4%, Async −0.0%, Callback −0.3%, Auto −0.2%, method −0.1%, ctor +0.0%.Follow-ups (not in this PR)